本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
這個系列文即將進入尾聲,是時候來驗收一下前面所學到的東西了,雖然不會所有的功能都在此次實戰演練中使用到,但我會盡量把一些我覺得很實用且常用的功能都納入考量,那就廢話不多說趕快開始吧!
這次的實戰演練會做一個簡單的「TodoList」,這個 TodoList 擁有基本的角色權限管理,並會有兩大資源,分別是:使用者 (user) 與 待辦事項 (todo)。而角色共會分成 系統管理者(admin)、管理員 (manager) 以及 成員 (member),他們各自擁有的操作權限如下:
專案架構預計會採用下方的分類方式來進行,這裡僅列出重點項目:
.
├─ .env
├─ src
|  ├─ common/
|  ├─ configs/
|  ├─ core/
|  ├─ features/
|  ├─ app.module.ts
|  └─ main.ts
└─ rbac
   ├─ model.conf
   └─ policy.csv
.env:環境變數配置檔。src/common:放一些共用的項目,如:constants、enums、models 等。src/configs:放環境變數相關的工廠函式。src/core:放一些與應用程式本身較有直接關聯的元件,如:guards、interceptors、pipes 等。src/features:主要功能放在這裡,像是這次會用到的 user、todo、auth 等。src/app.module.ts:根模組。src/main.ts:載入點。rbac:放置 Casbin 使用到的 model 與 policy。首先,透過 CLI 快速建立一個空白專案:
$ nest new <PROJECT_NAME>
接著,將我們會用到的相關套件透過 npm 進行安裝:
$ npm install @nestjs/config // 環境變數模組
$ npm install @nestjs/mapped-types // DTO 映射型別技巧用
$ npm install @nestjs/mongoose mongoose // 與 MongoDB 互動用
$ npm install @nestjs/passport passport // 身分驗證模組
$ npm install @nestjs/jwt passport-jwt // JWT 與它的驗證策略
$ npm install @types/passport-jwt -D // passport-jwt 的型別定義
$ npm install passport-local // 本地身分驗證策略
$ npm install @types/passport-local -D // 本地身分驗證策略的型別定義
$ npm install casbin // 授權套件
$ npm install class-validator class-transformer // DTO 使用的裝飾器
在開發過程中,我們會需要將 MongoDB 相關的敏感資訊以及 JWT 密鑰放在環境變數,所以在 .env 檔案裡進行配置:
MONGO_USERNAME=<YOUR_USERNAME>
MONGO_PASSWORD=<YOUR_PASSWORD>
MONGO_RESOURCE=<YOUR_RESOURCE>
JWT_SECRET=<YOUR_JSW_SECRET_KEY>
提醒:詳細環境變數之配置可以參考 DAY16 - Configuration。
我們可以先將 MongooseModule 在 AppModule 做配置,運用工廠函式配置環境變數命名空間的技巧,將 MongoDB 的相關環境變數用 mongo 這個命名空間群組在一起。在 configs 資料夾底下新增 mongo.config.ts:
import { registerAs } from '@nestjs/config';
export default registerAs('mongo', () => {
  const username = process.env.MONGO_USERNAME;
  const password = encodeURIComponent(process.env.MONGO_PASSWORD);
  const resource = process.env.MONGO_RESOURCE;
  const uri = `mongodb+srv://${username}:${password}@${resource}?retryWrites=true&w=majority`;
  return { username, password, resource, uri };
});
接著,在 AppModule 引入 ConfigModule 並進行相關配置,再將 MongoDB 需要用到的環境變數帶入 MongooseModule 中,進而建立連線:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}
提醒:詳細 mongoose 的使用方法可以參考 DAY22 - MongoDB,工廠函式配置環境變數命名空間可以參考 DAY16 - Configuration。
我們在實作身分驗證時會使用到 JWT,我們可以先把需要使用到的密鑰透過 secrets 這個命名空間來群組在一起。在 configs 資料夾中新增 secret.config.ts:
import { registerAs } from '@nestjs/config';
export default registerAs('secrets', () => {
  const jwt = process.env.JWT_SECRET;
  return { jwt };
});
接著,調整在 AppModule 中的 ConfigModule,多添加一個工廠函式在 load 中:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
})
export class AppModule {}
可以透過 Pipe 幫助 API 進行型別檢查,這裡可以運用 ValidationPipe 配置在全域的技巧來達成,我們只需要修改 AppModule 即可,在 providers 中運用自訂 Provider 的技巧來進行配置,provide 指定為 APP_PIPE,而 useClass 指定為 ValidationPipe:
import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}
提醒:全域 Pipe 的使用方法可以參考 DAY10 - Pipe (下)。
我會希望我們的 API 回傳格式式統一的,這對使用 API 的人來說是很重要的,而統一回傳格式這件事情最適合用 Interceptor 來實作了,直接將其配置在全域就可以套用到所有 API 上,十分方便!而我預期的格式如下,statusCode 即 HttpCode,oData 即回傳的資料:
{
  "statusCode": 200,
  "oData": {}
}
透過 CLI 快速產生一個 ResponseInterceptor在 core/interceptors 資料夾底下:
$ nest generate interceptor core/interceptors/response
接著,運用 RxJS 的 pipe 與 map 來達到格式統一的效果:
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const handler = next.handle();
    return handler.pipe(
      map((data) => {
        const response = context.switchToHttp().getResponse();
        return {
          statusCode: response.statusCode,
          oData: data,
        };
      }),
    );
  }
}
建立 index.ts 來做匯出管理:
export { ResponseInterceptor } from './response.interceptor';
最後,只需要在 AppModule 透過自訂 Provider 的方式進行全域配置即可:
import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ResponseInterceptor } from './core/interceptors';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [mongoConfigFactory, secretConfigFactory],
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        uri: config.get<string>('mongo.uri'),
        useFindAndModify: false,
      }),
    }),
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ResponseInterceptor,
    },
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}
提醒:Interceptor 的使用方法可以參考 DAY12 - Interceptor。
我希望我們設計的 API 都可以用 /api 作為路由前綴,但又不想要設計一個 ApiController,這時候可以直接在 main.ts 使用 app.setGlobalPrefix('api') 來達到我們要的效果:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  await app.listen(3000);
}
bootstrap();
在設計 API 前,我們先把要存入 MongoDB 的資料設計好 Schema,好讓我們之後可以使用 Model 來操作資料庫,以這次要設計的系統來說,共需要設計兩個 Schema,分別為:user 與 todo。
提醒:Schema 的設計方法可以參考 DAY22 - MongoDB。
這個專案所需的使用者資訊不必太多,只需要下方幾項即可:
username:使用者名稱,必填欄位,最小長度 6、最大長度 16。email:電子信箱,必填欄位。password:密碼,必填欄位,最小長度 8、最大長度 20。role:角色,必填欄位,接受的值為:admin、manager 以及 member,預設值為 member。在開始設計 UserSchema 之前,可以先將欄位的最大值、最小值、角色列表設計成常數與列舉,這樣在其他地方也能夠使用相同的限制條件。在 common/constants 資料夾下建立一個 user.const.ts:
export const USER_USERNAME_MIN_LEN = 6; // username 最小長度
export const USER_USERNAME_MAX_LEN = 16; // username 最大長度
export const USER_PASSWORD_MIN_LEN = 8; // password 最小長度
export const USER_PASSWORD_MAX_LEN = 20; // password 最大長度
接著,我們把角色列表做成列舉,在 common/enums 資料夾下新增 role.enum.ts:
export enum Role {
  ADMIN = 'admin',
  MANAGER = 'manager',
  MEMBER = 'member',
}
最後,就是來設計我們的 UserSchema,在 common/models 資料夾下建立 user.schema.ts:
import {
  ModelDefinition,
  Prop,
  raw,
  Schema,
  SchemaFactory,
} from '@nestjs/mongoose';
import { Document } from 'mongoose';
import {
  USER_USERNAME_MAX_LEN,
  USER_USERNAME_MIN_LEN,
} from '../constants/user.const';
import { Role } from '../enums/role.enum';
export type UserDocument = User & Document;
@Schema({ versionKey: false })
export class User {
  @Prop({
    required: true,
    minlength: USER_USERNAME_MIN_LEN,
    maxlength: USER_USERNAME_MAX_LEN,
  })
  username: string;
  @Prop({
    required: true,
  })
  email: string;
  @Prop({
    required: true,
    type: raw({
      hash: String,
      salt: String,
    }),
  })
  password: { hash: string; salt: string };
  @Prop({
    required: true,
    enum: Role,
    default: Role.MEMBER,
  })
  role: Role;
}
export const UserSchema = SchemaFactory.createForClass(User);
export const USER_MODEL_TOKEN = User.name;
export const UserDefinition: ModelDefinition = {
  name: USER_MODEL_TOKEN,
  schema: UserSchema,
};
會發現 password 並沒有用到我們定義好的限制條件,原因是存入資料庫的是 hash 與 salt,這個限制條件會放在 DTO 來做資料檢驗。
提醒:鹽加密的技巧可以參考 DAY23 - Authentication (上)。
以下為待辦事項所需的欄位:
title:待辦事項的標題,必填欄位,最小長度 3、最大長度 20。description:待辦事項的詳細描述,選填欄位,最大長度 200。completed:是否完成該待辦事項,必填欄位,預設為 false。將限制條件設計為常數,在 common/constants 資料夾下新增 todo.const.ts:
export const TODO_TITLE_MIN_LEN = 3; // title 最小長度
export const TODO_TITLE_MAX_LEN = 20; // title 最大長度
export const TODO_DESCRIPTION_MAX_LEN = 200; // description 最大長度
最後,在 common/models 資料夾下新增 todo.model.ts:
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import {
  TODO_DESCRIPTION_MAX_LEN,
  TODO_TITLE_MAX_LEN,
  TODO_TITLE_MIN_LEN,
} from '../constants/todo.const';
export type TodoDocument = Todo & Document;
@Schema({ versionKey: false })
export class Todo {
  @Prop({
    required: true,
    minlength: TODO_TITLE_MIN_LEN,
    maxlength: TODO_TITLE_MAX_LEN,
  })
  title: string;
  @Prop({
    maxlength: TODO_DESCRIPTION_MAX_LEN,
  })
  description?: string;
  @Prop({
    required: true,
    default: false,
  })
  completed: boolean;
}
export const TodoSchema = SchemaFactory.createForClass(Todo);
export const TODO_MODEL_TOKEN = Todo.name;
export const TodoDefinition: ModelDefinition = {
  name: TODO_MODEL_TOKEN,
  schema: TodoSchema,
};
今天先將一些基礎設施建立完畢,如:環境變數、MongoDB 的連線、Schema 的配置、統一回傳格式等,如此一來,後面的開發就可以基於這些東西繼續進行。下一篇就會開始設計 API 了,敬請期待!
賀!!!即將完賽! 感謝大大分享很實用的 NestJs 讓同為 Nodejs 的開發者學到很多
謝謝你的支持,能夠幫助到你我很高興